<?php

/*
Quad_Date
Copyright (c) 2009 Ken Snyder
http://code.google.com/p/quadphp

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

date_default_timezone_set(date_default_timezone_get());

/**
 * Extends DateTime to provide normaliztion and improvements
 */
class Quad_Date extends DateTime {
	
	/**
	 * When false, the date had a parse error
	 * @var bool
	 */
	public $isValid = true;
	
	/**
	 * A reference to the DateTimeZone object that belongs to this object
	 * @var DateTimeZone
	 */
	protected $_timezone;
	
	/**
	 * A string representing the date with timezone that is used for serialization
	 * @var string
	 */
	protected $_datestamp;
	
	/**
	 * Create a new date (@see Quad_Date::create and Quad_Date->setDate for arguments)
	 */
	public function __construct($date = null, $zoneOrOffset = null) {
		$args = func_get_args();
		call_user_func_array(array($this,'setDate'), $args);		
	}
	
	/**
	 * supported signatures:
	 * setDate($year, $month, $day)
	 * setDate($year, $month, $day, $hour, $minute, $second, $zoneOrOffset=null)
	 * setDate()
	 * setDate($unix, $zoneOrOffset=null)
	 * setDate(Quad_Date $date, $zoneOrOffset=null)
	 * setDate($dateString, $zoneOrOffset=null)
	 */
	public function setDate($date = null, $zoneOrOffset = null) {
		$this->isValid = true;		
		if (func_num_args() > 2) {
			// Ymd+ numbers
			$parts = array();
			// populate default date parts
			$parts = array(null,null,"01","00","00","00",null);
			$args = func_get_args();
			foreach ($args as $i => $a) {
				$parts[$i] = $a;
			}
			$zoneOrOffset = array_pop($parts);
			$date = $parts[0] . '-' . $parts[1] . '-' . $parts[2] . ' ' .
				$parts[3] . ':' . $parts[4] . ':' . $parts[5];								
//Quad_Debug::dumpdie('more than two args', $date, $parts, $args);				
		}		
		elseif (is_int($date) || is_float($date) || $date === null) {
			// single integer
//var_dump($date);
//die('setDate() number or null');			
			$this->setTimestamp($date);
			return $this;		
		}
		else {
			// string
			$date = func_get_arg(0);
		}
		// try to get new date and time
		try {
			$date = $this->_prefilterDateString($date);
//Quad_Debug::dumpdie('constructing...', $date);					
			if ($this->_timezone instanceof DateTimeZone) {
				parent::__construct($date, $this->_timezone);
			}
			else {
				parent::__construct($date);				
			}
		}
		catch (Exception $e) {
Quad_Debug::dump("DateTime Exception from creation param `$date`", $tz, $e->getMessage());						
			parent::__construct('0000-00-00');
		}
		$this->isValid = (parent::format('Y') != '-0001');
		
		if ($zoneOrOffset !== null) {
			$this->setTimezone($zoneOrOffset);			
		}		
		return $this;
	}	
	
	/**
	 * Return a new instance of Quad_Date (@see Quad_Date->setDate for arguments)
	 * @return Quad_Date
	 */
	public static function create($date = null, $zoneOrOffset = null) {
		if ($date instanceof self) {
			$date = clone $date;
			if ($zoneOrOffset !== null) {
				$date->setTimezone($zoneOrOffset);
			}
			return $date;
		}
		$a = func_get_args();
		switch (count($a)) {
			case 0:
				return new self();
			case 1:
				return new self($a[0]);
			case 2:
				return new self($a[0],$a[1]);
			case 3:
				return new self($a[0],$a[1],$a[2]);
			case 4:
				return new self($a[0],$a[1],$a[2],$a[3]);
			case 5:
				return new self($a[0],$a[1],$a[2],$a[3],$a[4]);
			case 6:
				return new self($a[0],$a[1],$a[2],$a[3],$a[4],$a[5]);
			case 7:
			default:
				return new self($a[0],$a[1],$a[2],$a[3],$a[4],$a[5],$a[6]);
		}
	}	
	
	/**
	 * Serialize value into a string
	 * @return array
	 */
	public function __sleep() {
		$this->_datestamp = $this->format(self::ISO8601);
		return array('_datestamp');
	}
	
	/**
	 * Unserialize string into date
	 * @return unknown_type
	 */
	public function __wakeup() {
		$this->setDate($this->_datestamp);
		unset($this->_datestamp);
	}	
	
	/**
	 * Getter for ->isValid
	 * @return bool
	 */
	public function isValid() {
		return $this->isValid;
	}
	
	/**
	 * Set only the year portion of a date
	 * @param string|int $Y
	 * @return $this
	 */
	public function setYear($Y) {
		$this->setDate($this->format("$Y-m-d H:i:s"));
		return $this;
	}
	
	/**
	 * Set only the month portion of a date
	 * @param string|int $m
	 * @return $this
	 */	
	public function setMonth($m) {
		$this->setDate($this->format("Y-$m-d H:i:s"));
		return $this;
	}
	
	/**
	 * Set only the day of month portion of a date
	 * @param string|int $d
	 * @return $this
	 */	
	public function setDay($d) {
		$this->setDate($this->format("Y-m-$d H:i:s"));
		return $this;
	}
	
	/**
	 * Set only the hour portion of a date
	 * @param string|int $H
	 * @return $this
	 */	
	public function setHour($H) {
		$this->setDate($this->format("Y-m-d $H:i:s"));
		return $this;
	}
	
	/**
	 * Set only the minutes portion of a date
	 * @param string|int $i
	 * @return $this
	 */	
	public function setMinute($i) {
		$this->setDate($this->format("Y-m-d H:$i:s"));
		return $this;
	}
	
	/**
	 * Set only the seconds portion of a date
	 * @param string|int $s
	 * @return $this
	 */	
	public function setSecond($s) {
		$this->setDate($this->format("Y-m-d H:i:$s"));
		return $this;
	}
	
	/**
	 * Get only the year portion of a date
	 * @return int
	 */	
	public function getYear() {
		return (int) $this->format('Y');
	}

	/**
	 * Get only the month portion of a date
	 * @return int
	 */	
	public function getMonth() {
		return (int) $this->format('n');
	}
	
	/**
	 * Get only the day of month portion of a date
	 * @return int
	 */	
	public function getDay() {
		return (int) $this->format('j');
	}	
	
	/**
	 * Get only the hour portion of a date
	 * @return int
	 */		
	public function getHour() {
		return (int) $this->format('H');
	}	
	
	/**
	 * Get only the minute portion of a date
	 * @return int
	 */		
	public function getMinute() {
		return (int) $this->format('i');
	}
	
	/**
	 * Get only the seconds portion of a date
	 * @return int
	 */		
	public function getSecond() {
		return (int) $this->format('s');
	}			

	/**
	 * Get only the day of the week (0=Sunday, 6=Saturday)
	 * @return int
	 */		
	public function getDayOfWeek() {
		return (int) $this->format('w');
	}
	
	/**
	 * Return true if day is M-F
	 * @return bool
	 */		
	public function isWeekday() {
		$dow = (int) $this->format('w');
		return ($dow >= 1 && $dow <= 5);
	}
	
	/**
	 * Return true if day is Saturday or Sunday
	 * @return bool
	 */
	public function isWeekend() {
		return !$this->isWeekday();
	}	
	
//	public function isBankHoliday() {
//		
//	}
	
	/**
	 * Set date to last day of the current month
	 * @return $this
	 */		
	public function setToLastDayOfMonth() {
		$this->setDay($this->getDaysInMonth());
		return $this;
	}
	
	/**
	 * Set date to the first day of the current month
	 * @return int
	 */
	public function setToFirstDayOfMonth() {		
		$this->setDay(1);
		return $this;
	}
	
	/**
	 * Set the date from a unix timestamp
	 * @param float|int $ts
	 * @return $this
	 */
	public function setTimestamp($ts = null) {
		$ts = $ts === null ? 'now' : "@" . (int) $ts;
		if ($this->_timezone instanceof DateTimeZone) {
	    parent::__construct($ts, $this->_timezone);
		}
		else {
			parent::__construct($ts);
		}
		return $this;
	}
	
	/**
	 * Get a unix timestamp representing the current date
	 * @return float
	 */		
	public function getTimestamp() {
		return (float) $this->format('U');
	}

	/**
	 * Support other date formats by applying regex rules
	 * @param string $date
	 * @return string
	 */
	protected function _prefilterDateString($date) {
		// accept month-day-year
		$date = preg_replace('/^(0?\d|1[0-2])-([0-2]?\d|3[01])-(\d{2,4})/','$3-$1-$2', $date);
		return $date;
	}
	
	/**
	 * Set hours, minutes and seconds
	 * @param string|int $H
	 * @param string|int $i
	 * @param string|int $s
	 * @return $this
	 */
	public function setTime($H,$i,$s) {
		parent::setTime($H,$i,$s);
//		if ($H < 10) {
//			$H = '0' . $H;
//		}
//		if ($i < 10) {
//			$i = '0' . $i;
//		}
//		if ($s < 10) {
//			$s = '0' . $s;
//		}				
//		$newStr = $this->format("Y-m-d") . " $H:$i:$s";
//		$this->setDate($newStr);
		return $this;
	}

	/**
	 * Check the validity of a year, month and day and set the date
	 * @param string|int $y
	 * @param string|int $m
	 * @param string|int $d
	 * @return null
	 */
	protected function _setDateYMD($y,$m,$d) {
		if (self::checkdate($y,$m,$d) == false) {
			$y = $m = $d = 0;
			$this->isValid = false;
		}
		else {
			$this->isValid = true;
		}
		parent::setDate($y,$m,$d);
	}
	
	/**
	 * Set both date and time
	 * @param string|int $y
	 * @param string|int $m
	 * @param string|int|int $d
	 * @param string|int $h
	 * @param string|int $i
	 * @param string|int $s
	 * @param string|int $zoneOrOffset
	 * @return null
	 */
	protected function _setDateYMDHIS($y,$m,$d,$h,$i,$s,$zoneOrOffset=null) {
		$this->_setDateYMD($y,$m,$d);
		parent::setTime($h,$i,$s);
		if ($zoneOrOffset !== null) {
			$this->setTimezone($zoneOrOffset);
		}
	}
	
	/**
	 * Set a timezone using a string or integer
	 * @param string|int $zoneOrOffset  Examples: -0600, +3:00, 4200, EST, America/Denver
	 * @return $this
	 */
	public function setTimezone($zoneOrOffset) {
		$oTimeZone = false;
		// string time offset such as -0600 or +3:00
		if (is_string($zoneOrOffset) && preg_match('/^([+-]?)([0-2]?\d):?([0-5]\d)$/', $zoneOrOffset, $match)) {
			list (, $sign, $hours, $minutes) = $match;
			$mult = ($sign == '-' ? -1 : 1);
			$zoneOrOffset = $mult * ((int) $hours * 60 + (int) $minutes) * 60;
//die("{$match[0]} -- $zoneOrOffset");						
		}
		if ($zoneOrOffset instanceof DateTimeZone) {
			$oTimeZone = $zoneOrOffset;
		}
		elseif (is_int($zoneOrOffset) || is_string($zoneOrOffset) || is_float($zoneOrOffset)) {
			try {
				// integer offset
				if (is_int($zoneOrOffset)) {
					$abbr = DateTimeZone::listAbbreviations();
					foreach ($abbr as $name => $zones) {
						if ((int) $zones[0]['offset'] == $zoneOrOffset) {
							$zone = $zones[0]['timezone_id'];
//die("integer $zoneOrOffset -- $zone");
							$oTimeZone = new DateTimeZone($zone);
							break;
						}
					}
				}
				// abbreviation such as EST or GMT
				elseif (preg_match('/^[a-z]+$/i', $zoneOrOffset)) {
					$zoneOrOffset = strtolower($zoneOrOffset);
					$abbr = DateTimeZone::listAbbreviations();
					if (isset($abbr[$zoneOrOffset])) {
						$zone = $abbr[$zoneOrOffset][0]['timezone_id'];
//die("abbr $zoneOrOffset -- $zone");						
						$oTimeZone = new DateTimeZone($zone);
					}
				}
				// normal zone name for DateTimeZone such as America/Denver
				elseif (strpos($zoneOrOffset,'/') > 1) {
//die("normal $zoneOrOffset");
					$oTimeZone = new DateTimeZone($zoneOrOffset);
				}
			}
			catch (Exception $e) {
				// tz is invalid
Quad_Debug::dumpdie('tz is invalid:', $e->getMessage(), $zoneOrOffset);
				$oTimeZone = false;
			}
		}
		if ($oTimeZone instanceof DateTimeZone) {
			parent::setTimeZone($oTimeZone);
			$this->_timezone = $oTimeZone;
		}
		else {
			$this->getTimezone();
		}
		return $this;
	}
	
	/**
	 * Get the DateTimeZone object that is a part of this date
	 * @return DateTimeZone
	 */
	public function getTimezone() {
		if (!$this->_timezone) {
			$tz = new DateTimeZone(date_default_timezone_get());
			$this->_timezone = $tz;
			parent::setTimezone($tz);
		}
		return parent::getTimezone();
	}
	
	/**
	 * Get the number of days in the current month
	 * @return int
	 */
	public function getDaysInMonth() {
		list($year, $month) = explode(' ', $this->format('Y n'));
		return self::daysInMonth($year, $month);
	}
	
	/**
	 * Static function to get the number of days in the given year and month
	 * @param string|int $year
	 * @param string|int $month
	 * @return int
	 */
	public static function daysInMonth($year, $month) {
		$month = (int) $month;
		if ($month == 2) {
			$test = new DateTime("$year-2-29");
			return ($test->format('j') == '29' ? 29 : 28);
		}
		static $map = array(31,null,31,30,31,30,31,31,30,31,30,31);
		return $map[$month-1];
	}
	
	/**
	 * Check that a date is valid given year, month and day
	 * @param string|int $y
	 * @param string|int $m
	 * @param string|int $d
	 * @return bool
	 */
	public static function checkdate($y,$m,$d) {
		$y = (int) $y;
		$m = (int) $m;
		$d = (int) $d;
		if (
			($y < -9999) ||
			($y > 9999) ||
			($y === 0) ||
			($m < 1) ||
			($m > 12) ||
			($d < 1) ||
			($d > 31) ||
			($d > 28 && $d > self::daysInMonth($y,$m))
		) {
			return false;
		}
		return true;
	}
	
	public function equals($d) {
		return $this == self::create($d);	
	}	
		
	public function isBetween($d1, $d2, $inclusive = true) {
		$d1 = self::create($d1);
		$d2 = self::create($d2);
		if ($inclusive) {
			return ($this >= $d1 && $this <= $d2);
		}
		return ($this > $d1 && $this < $d2);
	}
	
	public function isBefore($d1) {
		$d1 = self::create($d1);
		return ($this < $d1);
	}
	
	public function isAfter($d1) {
		$d1 = self::create($d1);
		return ($this > $d1);
	}
	
	public function min() {
		$args = func_get_args();
		if (count($args) == 1 && is_array($args[0])) {
			$args = $args[0];
		}
		$lowest = $this;
		
		foreach ($args as $d1) {
			$d1 = self::create($d1);
			if ($d1 < $this) {
				$lowest = $d1;
			}
		}
		return $lowest;
	}
	
	public function max() {
		$args = func_get_args();
		if (count($args) == 1 && is_array($args[0])) {
			$args = $args[0];
		}
		$highest = $this;
		
		foreach ($args as $d1) {
			$d1 = self::create($d1);
			if ($d1 > $this) {
				$highest = $d1;
			}
		}
		return $highest;
	}	
		
	public function diff($date, $absolute = false) {
		$interval = new Quad_Date_Interval(array($date, $this), $absolute);
		return $interval;
	}
	
	public function add($amount) {
		$new = clone $this;
		$new->modify($amount);
		return $new;
	}
	
	public function sub($amount) {
		$amount = trim($amount);
		if (substr($amount,0,1) == '-') {
			$amount = '+' . substr($amount,1);
		}
		if (substr($amount,0,1) == '+') {
			$amount = '-' . substr($amount,1);
		}
		else {
			$amount = '-' . $amount;
		}
		return $this->add($amount);
	}
	
	public function modify($amount) {
		parent::modify($amount);
		$this->isValid = $this->format('Y') != '-0001';
		return $this; // null is returned before php 5.3
	}
	
	public function format($code) {
//		if (!$this->isValid) {
//			return false;
//		}		
		if (strpos($code, '%') !== false) {
			$code = preg_replace_callback('/%#?[a-z]/i', array('self','pregReplaceFormat'), $code);
		}		
		return parent::format($code);
	}
	
	public function __toString() {
		if (!$this->isValid) {
			return '0000-00-00 00:00:00';
		}
//var_dump(parent::format('Y-m-d H:i:s'));		
//die('toString' . json_encode($this));		
		return parent::format('Y-m-d H:i:s');
	}

//	public function __clone() {
//		$this->raw = clone $this->raw;
//		if ($this->_timezone) {
//			$this->raw->setTimezone(clone $this->_timezone);
//		}
//	}
	
	// chainable cloning
	public function copy() {
		return clone $this;
	}
	
	public static function pregReplaceFormat($match) {
		$letter = $match[0];
		// convert % style codes to php style codes
		static $map = array(
			// year
			'%Y'=>'Y', // 4 digit year
			'%y'=>'y', // 2 digit year
			// month
			'%m'=>'m', // 2 digit month
			'%#m'=>'n', // 1 digit month
			'%b'=>'M', // Abbr. Month name
			'%B'=>'F', // Full month name
			// day
			'%d'=>'d', // 2 digit day
			'%#d'=>'j', // 1 digit day
			'%e'=>'j', // 1 digit day
			'%a'=>'D', // Abbr. Day Name
			'%A'=>'l', // Full Month Name
			'%o'=>'S', // Day Ordinal
			'%w'=>'w', // day of week (0=sun 6=sat)
			// hours
			'%H'=>'H', // 2 digit hours (24hr clock)
			'%#H'=>'h', // 1 digit hours (24hr clock)
			'%I'=>'G', // 2 digit hours (12hr clock)
			'%#I'=>'g', // 1 digit hours (12hr clock)
			'%p'=>'a', // AM or PM
			// minutes
			'%M'=>'i', // 2 digit minutes
			// seconds
			'%S'=>'s', // 2 digit seconds
			'%s'=>'U', // unix timestamp
			// timezone
			'%O'=>'p', // Time zone offset minutes
			'%Z'=>'e', // Time zone name
			'%G'=>'Z', // GMT Offset
			// shortcuts
			'%F'=>'Y-m-d',
			'%T'=>'H:i:s',
			'%X'=>'H:i:s',
			'%x'=>'m/d/y',
			'%D'=>'m/d/y',
			'%c'=>'D M j H:i:s Y',
			'%v'=>'j-M-Y',
			'%R'=>'H:i',
			'%r'=>'G:i:s p',
		);
		if (isset($map[$letter])) {
			return $map[$letter];
		}
		return $letter;
	}
	
	public static function textToRange($text, $format=null) {
		if (preg_match('/last (\d+) (second|minute|hour|day|week|month|year)s?/i', $text, $match)) {
			$begin = strtotime($match[1] . ' ' . $match[2] . ' ago');
			$end = time();
		}
		else {
			$text = strtolower($text);
			switch ($text) {
				case 'yesterday':
					$yesterdate = date('Y-m-d', strtotime('yesterday'));
					$begin = "$yesterdate 00:00:00";
					$end = "$yesterdate 23:59:59";
					break;
				case 'today':
					$today = date('Y-m-d');
					$begin =  "$today 00:00:00";
					$end = "$today 23:59:59";
					break;
				case 'last calendar month':
					$lastMonth = date('Y-m-01', strtotime('last month'));
					$firstOfMonth = self::create($lastMonth);
					$daysInMonth = $firstOfMonth->daysInMonth();
					$begin = $firstOfMonth->format('Y-m-d H:i:s');
					$end = substr($lastMonth,0,7) . "$daysInMonth 23:59:59";
					break;					
				case 'this calendar month':
					$lastMonth = date('Y-m-01');
					$firstOfMonth = self::create($lastMonth);
					$daysInMonth = $firstOfMonth->daysInMonth();
					$begin = $firstOfMonth->format('Y-m-d H:i:s');
					$end = substr($lastMonth,0,7) . "-$daysInMonth 23:59:59";
					break;					
				case 'last calendar year':
					$begin = date('Y-01-01 00:00:00', strtotime('last year'));
					$end = substr($begin,0,4) . "-12-31 23:59:59";
					break;					
				case 'this calendar year':
					$begin = date('Y-01-01 00:00:00');
					$end = substr($begin,0,4) . "-12-31 23:59:59";
					break;						
			}
		}
		return array(self::create($begin), self::create($end));
	}
	
	public function getCalendarMonth() {
		return new Quad_Date_Calendar_Month($this);
	}
	
	public function getAge() {
		return $this->diff(self::create())->inYears();
	}
	
}
